Coverage Report

Created: 2025-11-30 10:47

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\src\cli.rs
Line
Count
Source
1
//! CLI interface
2
3
use crate::client::main as client_main;
4
use crate::daemon::{main as daemon_main, resolve_cluster_tags};
5
use crate::utils::config::{ClientConfig, Cluster, Config, ConfigOpt, DaemonConfig};
6
use crate::utils::windows::WindowsApi;
7
use crate::{
8
    get_console_window_handle, init_logger, is_launched_from_gui, spawn_console_process,
9
    WindowsSettingsDefaultTerminalApplicationGuard,
10
};
11
use clap::{ArgAction, CommandFactory, Parser, Subcommand};
12
13
#[cfg(test)]
14
use mockall::{automock, predicate::*};
15
use windows::Win32::UI::HiDpi::PROCESS_PER_MONITOR_DPI_AWARE;
16
17
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
18
19
/// Cluster SSH tool for Windows inspired by csshX
20
///
21
/// The main CLI arguments
22
#[derive(Parser, Debug)]
23
#[clap(author, version, about, long_about = None)]
24
pub struct Args {
25
    /// Optional subcommand
26
    /// Usually not specified by the user
27
    #[clap(subcommand)]
28
    command: Option<Commands>,
29
    /// Optional username used to connect to the hosts
30
    #[clap(long, short = 'u')]
31
    username: Option<String>,
32
    /// Optional port used for all SSH connections
33
    #[clap(long, short = 'p')]
34
    port: Option<u16>,
35
    /// Hosts and/or cluster tag(s) to connect to
36
    ///
37
    /// Hosts or cluster tags might use brace expansion,
38
    /// but need to be properly quoted.
39
    ///
40
    /// E.g.: `csshw.exe "host{1..3}" hostA`
41
    ///
42
    /// Hosts can include a username which will take precedence over the
43
    /// username given via the `-u` option and over any ssh config value.
44
    ///
45
    /// E.g.: `csshw.exe -u user3 user1@host1 userA@hostA host3`
46
    ///
47
    /// Hosts can include a port number which will take precedence over the
48
    /// port given via the `-p` option.
49
    ///
50
    /// E.g.: `csshw.exe -p 33 host1:11 host2:22 host3`
51
    ///
52
    /// If no hosts are provided and the application is launched in a new console window
53
    /// (e.g. by double clicking the executable in the File Explorer),
54
    /// it will launch in interactive mode.
55
    #[clap(required = false, global = true)]
56
    hosts: Vec<String>,
57
    /// Enable extensive logging
58
    #[clap(short, long, action=ArgAction::SetTrue)]
59
    debug: bool,
60
}
61
62
/// The ``command`` CLI subcommand
63
#[derive(Debug, Subcommand, PartialEq)]
64
enum Commands {
65
    /// Subcommand that will launch a single client window
66
    ///
67
    /// connecting to the given host with the given username.
68
    /// It will also try to read input from a daemon via the named pipe.
69
    Client {
70
        /// Host to connect to
71
        host: String,
72
    },
73
    /// Subcommand that will launch the daemon window.
74
    ///
75
    /// The daemon is responsible to launch the client windows,
76
    /// one for each given host.
77
    /// For each client a named pipe will be created and any keystrokes
78
    /// the daemon window receives are forwarded via the pipes to all the clients.
79
    /// Also handles control mode.
80
    Daemon {},
81
}
82
83
/// Main Entrypoint struct
84
///
85
/// Used to implement the entrypoint functions of the different
86
/// subcommands
87
pub struct MainEntrypoint;
88
89
/// Trait for Args operations to enable mocking in tests
90
#[cfg_attr(test, automock)]
91
pub trait ArgsCommand {
92
    /// Print help message
93
    fn print_help(&self) -> Result<(), std::io::Error>;
94
}
95
96
/// Default implementation of ArgsCommand trait
97
pub struct CLIArgsCommand;
98
99
impl ArgsCommand for CLIArgsCommand {
100
0
    fn print_help(&self) -> Result<(), std::io::Error> {
101
0
        return Args::command().print_help();
102
0
    }
103
}
104
105
/// Trait for logger initialization to enable mocking in tests
106
#[cfg_attr(test, automock)]
107
pub trait LoggerInitializer {
108
    /// Initialize logger with the given name
109
    fn init_logger(&self, name: &str);
110
}
111
112
/// Default implementation of LoggerInitializer trait
113
pub struct CLILoggerInitializer;
114
115
impl LoggerInitializer for CLILoggerInitializer {
116
0
    fn init_logger(&self, name: &str) {
117
0
        init_logger(name);
118
0
    }
119
}
120
121
/// Trait for writing output to enable dependency injection and testing
122
#[cfg_attr(test, automock)]
123
pub trait Output {
124
    /// Write a line to the output
125
    fn println(&mut self, text: &str);
126
    /// Write text without a newline to the output
127
    fn print(&mut self, text: &str);
128
    /// Write a line to stderr
129
    fn eprintln(&mut self, text: &str);
130
    /// Flush the output
131
    fn flush(&mut self);
132
}
133
134
/// Default implementation of Output trait that writes to stdout/stderr
135
pub struct CLIOutput;
136
137
impl Output for CLIOutput {
138
0
    fn println(&mut self, text: &str) {
139
0
        println!("{text}");
140
0
    }
141
142
0
    fn print(&mut self, text: &str) {
143
0
        print!("{text}");
144
0
    }
145
146
0
    fn eprintln(&mut self, text: &str) {
147
0
        eprintln!("{text}");
148
0
    }
149
150
0
    fn flush(&mut self) {
151
        use std::io::Write;
152
0
        std::io::stdout().flush().unwrap();
153
0
    }
154
}
155
156
/// Trait for reading input to enable dependency injection and testing
157
#[cfg_attr(test, automock)]
158
pub trait Input {
159
    /// Read a line from stdin
160
    fn read_line(&mut self) -> Result<String, std::io::Error>;
161
}
162
163
/// Default implementation of Input trait that reads from stdin
164
pub struct CLIInput;
165
166
impl Input for CLIInput {
167
0
    fn read_line(&mut self) -> Result<String, std::io::Error> {
168
0
        let mut input = String::new();
169
0
        std::io::stdin().read_line(&mut input)?;
170
0
        return Ok(input);
171
0
    }
172
}
173
174
/// Trait for environment operations to enable dependency injection and testing
175
#[cfg_attr(test, automock)]
176
pub trait Environment {
177
    /// Get current executable path
178
    fn current_exe(&self) -> Result<std::path::PathBuf, std::io::Error>;
179
    /// Set current directory
180
    fn set_current_dir(&self, path: &std::path::Path) -> Result<(), std::io::Error>;
181
}
182
183
/// Default implementation of Environment trait
184
pub struct CLIEnvironment;
185
186
impl Environment for CLIEnvironment {
187
0
    fn current_exe(&self) -> Result<std::path::PathBuf, std::io::Error> {
188
0
        return std::env::current_exe();
189
0
    }
190
191
0
    fn set_current_dir(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
192
0
        return std::env::set_current_dir(path);
193
0
    }
194
}
195
196
/// Trait for configuration management to enable dependency injection and testing
197
#[cfg_attr(test, automock)]
198
pub trait ConfigManager {
199
    /// Load configuration from the specified path
200
    fn load_config(&self, path: &str) -> Result<ConfigOpt, confy::ConfyError>;
201
    /// Store configuration to the specified path
202
    fn store_config(&self, path: &str, config: &Config) -> Result<(), confy::ConfyError>;
203
}
204
205
/// Default implementation of ConfigManager trait
206
pub struct CLIConfigManager;
207
208
impl ConfigManager for CLIConfigManager {
209
0
    fn load_config(&self, path: &str) -> Result<ConfigOpt, confy::ConfyError> {
210
0
        return confy::load_path(path);
211
0
    }
212
213
0
    fn store_config(&self, path: &str, config: &Config) -> Result<(), confy::ConfyError> {
214
0
        return confy::store_path(path, config);
215
0
    }
216
}
217
218
/// Trait defining the entrypoint functions of the different
219
/// subcommands
220
#[cfg_attr(test, automock)]
221
pub trait Entrypoint {
222
    /// Entrypoint for the client subcommand
223
    fn client_main<W: WindowsApi + 'static>(
224
        &mut self,
225
        windows_api: &W,
226
        host: String,
227
        username: Option<String>,
228
        port: Option<u16>,
229
        config: &ClientConfig,
230
    ) -> impl std::future::Future<Output = ()> + Send;
231
    /// Entrypoint for the daemon subcommand
232
    fn daemon_main<W: WindowsApi + Clone + 'static>(
233
        &mut self,
234
        windows_api: &W,
235
        hosts: Vec<String>,
236
        username: Option<String>,
237
        port: Option<u16>,
238
        config: &DaemonConfig,
239
        clusters: &[Cluster],
240
        debug: bool,
241
    ) -> impl std::future::Future<Output = ()> + Send;
242
    /// Entrypoint for the main command
243
    fn main<W: WindowsApi + 'static, C: ConfigManager + 'static>(
244
        &mut self,
245
        windows_api: &W,
246
        config_manager: &C,
247
        config_path: &str,
248
        config: &Config,
249
        args: Args,
250
    );
251
}
252
253
impl Entrypoint for MainEntrypoint {
254
0
    async fn client_main<W: WindowsApi>(
255
0
        &mut self,
256
0
        windows_api: &W,
257
0
        host: String,
258
0
        username: Option<String>,
259
0
        port: Option<u16>,
260
0
        config: &ClientConfig,
261
0
    ) {
262
0
        client_main(windows_api, host, username, port, config).await;
263
0
    }
264
265
0
    async fn daemon_main<W: WindowsApi + Clone + 'static>(
266
0
        &mut self,
267
0
        windows_api: &W,
268
0
        hosts: Vec<String>,
269
0
        username: Option<String>,
270
0
        port: Option<u16>,
271
0
        config: &DaemonConfig,
272
0
        clusters: &[Cluster],
273
0
        debug: bool,
274
0
    ) {
275
0
        daemon_main(windows_api, hosts, username, port, config, clusters, debug).await;
276
0
    }
277
278
7
    fn main<W: WindowsApi + 'static, C: ConfigManager + 'static>(
279
7
        &mut self,
280
7
        windows_api: &W,
281
7
        config_manager: &C,
282
7
        config_path: &str,
283
7
        config: &Config,
284
7
        args: Args,
285
7
    ) {
286
7
        config_manager.store_config(config_path, config).unwrap();
287
288
7
        let mut daemon_args: Vec<String> = Vec::new();
289
7
        if args.debug {
290
2
            daemon_args.push("-d".to_string());
291
5
        }
292
7
        if let Some(
username3
) = args.username {
293
3
            daemon_args.push("-u".to_string());
294
3
            daemon_args.push(username);
295
4
        }
296
7
        if let Some(
port3
) = args.port {
297
3
            daemon_args.push("-p".to_string());
298
3
            daemon_args.push(port.to_string());
299
4
        }
300
7
        daemon_args.push("daemon".to_string());
301
        // Order is important here. If the hosts are passed before the daemon subcommand
302
        // it will not be recognizes as such and just be passed along as one of the hosts.
303
7
        daemon_args.extend(
304
7
            resolve_cluster_tags(
305
9
                
args.hosts.iter()7
.
map7
(|host| return &**host).
collect7
(),
306
7
                &config.clusters,
307
            )
308
7
            .into_iter()
309
9
            .
map7
(|host| return host.to_string()),
310
        );
311
7
        let _guard = WindowsSettingsDefaultTerminalApplicationGuard::new();
312
        // We must wait for the window to actually launch before dropping the _guard as we might otherwise
313
        // reset the configuration before the window was launched
314
7
        let _ = get_console_window_handle(
315
7
            windows_api,
316
7
            spawn_console_process(windows_api, &format!("{PKG_NAME}.exe"), daemon_args)
317
7
                .expect("Failed to create process")
318
7
                .dwProcessId,
319
7
        );
320
7
    }
321
}
322
323
/// Display the interactive mode prompt and instructions
324
9
fn show_interactive_prompt<O: Output>(output: &mut O) {
325
9
    output.println("\n=== Interactive Mode ===");
326
9
    output.println(&format!(
327
9
        "Enter your {PKG_NAME} arguments (or press Enter to exit):"
328
9
    ));
329
9
    output.println("Example: -u myuser host1 host2 host3");
330
9
    output.println("Example: --help");
331
9
    output.print("> ");
332
9
    output.flush();
333
9
}
334
335
/// Read user input from stdin
336
///
337
/// # Arguments
338
///
339
/// * `input` - The Input trait object for reading from stdin
340
///
341
/// # Returns
342
///
343
/// * `Ok(Some(input))` - User provided input
344
/// * `Ok(None)` - User wants to exit (empty input or "exit")
345
/// * `Err(error)` - Error reading input
346
13
fn read_user_input<I: Input>(input: &mut I) -> Result<Option<String>, std::io::Error> {
347
13
    let 
input_line11
= input.read_line()
?2
;
348
349
11
    let input_trimmed = input_line.trim();
350
11
    if input_trimmed.is_empty() || 
input_trimmed.to_lowercase() == "exit"6
{
351
7
        return Ok(None);
352
4
    }
353
354
4
    return Ok(Some(input_trimmed.to_string()));
355
13
}
356
357
/// Handle special commands that don't need full parsing
358
///
359
/// # Arguments
360
///
361
/// * `input` - The user input string
362
/// * `args_command` - The ArgsCommand trait object for printing help
363
///
364
/// # Returns
365
///
366
/// * `true` - Command was handled, continue loop
367
/// * `false` - Command needs full parsing
368
12
fn handle_special_commands<A: ArgsCommand>(input: &str, args_command: &A) -> bool {
369
12
    if input == "--help" || 
input == "-h"10
{
370
3
        let _ = args_command.print_help();
371
3
        return true;
372
9
    }
373
9
    return false;
374
12
}
375
376
/// Execute a parsed command using the provided entrypoint
377
11
async fn execute_parsed_command<
378
11
    W: WindowsApi + Clone + 'static,
379
11
    T: Entrypoint,
380
11
    A: ArgsCommand,
381
11
    L: LoggerInitializer,
382
11
    C: ConfigManager + 'static,
383
11
>(
384
11
    windows_api: &W,
385
11
    parsed_args: Args,
386
11
    entrypoint: &mut T,
387
11
    args_command: &A,
388
11
    logger_initializer: &L,
389
11
    config_manager: &C,
390
11
    config: &Config,
391
11
    config_path: &str,
392
11
) {
393
6
    match &parsed_args.command {
394
3
        Some(Commands::Client { host }) => {
395
3
            if parsed_args.debug {
396
1
                logger_initializer.init_logger(&format!("csshw_client_{host}"));
397
2
            }
398
3
            entrypoint
399
3
                .client_main(
400
3
                    windows_api,
401
3
                    host.to_owned(),
402
3
                    parsed_args.username.to_owned(),
403
3
                    parsed_args.port,
404
3
                    &config.client,
405
3
                )
406
3
                .await;
407
        }
408
        Some(Commands::Daemon {}) => {
409
3
            if parsed_args.debug {
410
2
                logger_initializer.init_logger("csshw_daemon");
411
2
            
}1
412
3
            entrypoint
413
3
                .daemon_main(
414
3
                    windows_api,
415
3
                    parsed_args.hosts,
416
3
                    parsed_args.username,
417
3
                    parsed_args.port,
418
3
                    &config.daemon,
419
3
                    &config.clusters,
420
3
                    parsed_args.debug,
421
3
                )
422
3
                .await;
423
        }
424
        None => {
425
5
            if !parsed_args.hosts.is_empty() {
426
3
                entrypoint.main(
427
3
                    windows_api,
428
3
                    config_manager,
429
3
                    config_path,
430
3
                    config,
431
3
                    parsed_args,
432
3
                );
433
3
            } else {
434
2
                // Show help for empty hosts
435
2
                let _ = args_command.print_help();
436
2
            }
437
        }
438
    }
439
11
}
440
441
/// Run the interactive mode loop for GUI launches
442
4
async fn run_interactive_mode<
443
4
    W: WindowsApi + Clone + 'static,
444
4
    A: ArgsCommand,
445
4
    L: LoggerInitializer,
446
4
    T: Entrypoint,
447
4
    O: Output,
448
4
    I: Input,
449
4
    C: ConfigManager + 'static,
450
4
>(
451
4
    windows_api: &W,
452
4
    args_command: &A,
453
4
    logger_initializer: &L,
454
4
    mut entrypoint: T,
455
4
    config_manager: &C,
456
4
    config: &Config,
457
4
    config_path: &str,
458
4
    output: &mut O,
459
4
    input: &mut I,
460
4
) {
461
    loop {
462
8
        show_interactive_prompt(output);
463
464
8
        match read_user_input(input) {
465
3
            Ok(Some(input_str)) => {
466
                // Handle special commands first
467
3
                if handle_special_commands(&input_str, args_command) {
468
1
                    continue;
469
2
                }
470
471
                // Parse the input as command line arguments
472
2
                let input_args: Vec<&str> = input_str.split_whitespace().collect();
473
2
                let mut full_args = vec![PKG_NAME];
474
2
                full_args.extend(input_args);
475
476
2
                match Args::try_parse_from(full_args) {
477
1
                    Ok(parsed_args) => {
478
1
                        execute_parsed_command(
479
1
                            windows_api,
480
1
                            parsed_args,
481
1
                            &mut entrypoint,
482
1
                            args_command,
483
1
                            logger_initializer,
484
1
                            config_manager,
485
1
                            config,
486
1
                            config_path,
487
1
                        )
488
1
                        .await;
489
                    }
490
1
                    Err(err) => {
491
1
                        output.eprintln(&format!("\nError parsing arguments: {err}"));
492
1
                    }
493
                }
494
            }
495
            Ok(None) => {
496
4
                return;
497
            }
498
1
            Err(err) => {
499
1
                output.eprintln(&format!("Error reading input: {err}"));
500
1
            }
501
        }
502
    }
503
4
}
504
505
/// The main entrypoint
506
///
507
/// Parses the CLI arguments,
508
/// loads an existing config or writes the default config to disk, and
509
/// calls the respective subcommand.
510
/// If no subcommand is given we launch the daemon subcommand in a new window.
511
10
pub async fn main<
512
10
    W: WindowsApi + Clone + 'static,
513
10
    E: Entrypoint,
514
10
    O: Output,
515
10
    I: Input,
516
10
    Env: Environment,
517
10
    A: ArgsCommand,
518
10
    L: LoggerInitializer,
519
10
    C: ConfigManager + 'static,
520
10
>(
521
10
    windows_api: &W,
522
10
    args: Args,
523
10
    mut entrypoint: E,
524
10
    output: &mut O,
525
10
    input: &mut I,
526
10
    environment: &Env,
527
10
    args_command: &A,
528
10
    logger_initializer: &L,
529
10
    config_manager: &C,
530
10
) {
531
    // CRITICAL: Check GUI launch BEFORE any output to console
532
10
    let launched_from_gui = is_launched_from_gui(windows_api);
533
534
    // Set DPI awareness programatically. Using the manifest is the recommended way
535
    // but conhost.exe does not do any manifest loading.
536
    // https://github.com/microsoft/terminal/issues/18464#issuecomment-2623392013
537
10
    if let Err(
err4
) = windows_api.set_process_dpi_awareness(PROCESS_PER_MONITOR_DPI_AWARE) {
538
4
        output.eprintln(&format!(
539
4
            "Failed to set DPI awareness programatically: {err:?}"
540
4
        ));
541
6
    }
542
10
    match environment.current_exe() {
543
9
        Ok(path) => match path.parent() {
544
1
            None => {
545
1
                output.eprintln("Failed to get executable path parent working directory");
546
1
            }
547
8
            Some(exe_dir) => {
548
8
                environment
549
8
                    .set_current_dir(exe_dir)
550
8
                    .expect("Failed to change current working directory");
551
8
            }
552
        },
553
1
        Err(_) => {
554
1
            output.eprintln("Failed to get executable directory");
555
1
        }
556
    }
557
558
10
    let config_path = format!("{PKG_NAME}-config.toml");
559
10
    let config_on_disk: ConfigOpt = config_manager.load_config(&config_path).unwrap();
560
10
    let config: Config = config_on_disk.into();
561
562
4
    match &args.command {
563
2
        Some(Commands::Client { host }) => {
564
2
            if args.debug {
565
1
                logger_initializer.init_logger(&format!("csshw_client_{host}"));
566
1
            }
567
2
            entrypoint
568
2
                .client_main(
569
2
                    windows_api,
570
2
                    host.to_owned(),
571
2
                    args.username.to_owned(),
572
2
                    args.port,
573
2
                    &config.client,
574
2
                )
575
2
                .await;
576
        }
577
        Some(Commands::Daemon {}) => {
578
2
            if args.debug {
579
1
                logger_initializer.init_logger("csshw_daemon");
580
1
            }
581
2
            entrypoint
582
2
                .daemon_main(
583
2
                    windows_api,
584
2
                    args.hosts.to_owned(),
585
2
                    args.username.clone(),
586
2
                    args.port,
587
2
                    &config.daemon,
588
2
                    &config.clusters,
589
2
                    args.debug,
590
2
                )
591
2
                .await;
592
        }
593
        None => {
594
            // If no hosts provided, show help and handle GUI vs console launch
595
6
            if args.hosts.is_empty() {
596
5
                let _ = args_command.print_help();
597
598
                // If launched from GUI, allow user to input arguments interactively
599
5
                if launched_from_gui {
600
1
                    run_interactive_mode(
601
1
                        windows_api,
602
1
                        args_command,
603
1
                        logger_initializer,
604
1
                        entrypoint,
605
1
                        config_manager,
606
1
                        &config,
607
1
                        &config_path,
608
1
                        output,
609
1
                        input,
610
1
                    )
611
1
                    .await;
612
4
                }
613
5
                return;
614
1
            }
615
616
1
            entrypoint.main(windows_api, config_manager, &config_path, &config, args);
617
        }
618
    }
619
10
}
620
621
#[cfg(test)]
622
#[path = "./tests/test_cli.rs"]
623
mod test_cli;